[기초부터 완성까지 프론트엔드 5장]

5장

프로토타입

자바스크립트에서는 프로토타입을 기반으로 객체 지향의 상속 개념을 구현합니다. 모든 객체는 자신의 부모 역할을 하는 프로토타입 객체의 참조링크를 가지고 있으며, 이 링크를 통해 프로토타입으로부터 프로퍼티나 메서드를 상속받을 수 있습니다. 또한 프로토타입 역시 또 다른 상위 프로토타입으로부터 프로퍼티나 메서드를 상속받을 수 있습니다.

객체의 프로토타입은 참조 링크 형태로 [[Prototype]] 내부 프로퍼티에 저장됩니다. 참조 링크 형태로 저장되기 때문에 동일한 프로토타입을 상속받은 객체는 모두 같은 프로퍼티와 메서드를 공유합니다.

obj객체에서 toString() 메서드를 호출했다. 선언된 메서드가 obj 객체 내에 없는데 어떻게 함수 호출이 성공했을까

const obj = {
  name: 'javascript',
}

console.log(obj.toString()) // '[Object object]'

이것은 프로토타입 체인이라는 개념 때문에 가능합니다. 프로토타입 체인은 상위 프로토타입과 연쇄적으로 연결된 구조를 의미합니다. 그리고 프로퍼티나 메서드에 접근하기 위해 이 연결 구조를 따라 차례대로 검색하는 것을 프로토타입 체이닝이라고 합니다. 예제 코드는 다음의 과정처럼 프로토타입 체인을 통해 toString() 메서드가 호출된 것입니다.

정리하자면 obj 객체의 toString() 메서드를 호출하기 위해 obj 객체의 프로퍼티나 메서드를 검색하고, 찾지못하면 프로토타입 체인을 통해 상위 프로토타입에서 toString() 메서드를 검색합니다. 그리고 상위 프로토타입에서 찾으면 그 메서드를 호출합니다.

최상위 프로토타입

Object.prototype은 프로토타입 체인의 최상위에 있는 프로토타입입니다. 모든 객체가 가진 프로토타입 체인의 종점은 모두 Object.prototype입니다. 즉 모든 객체가 Object.prototype을 프로토타입으로 공유한다는 의미입니다.

다양한 객체의 프로토타입

const arr = []

arr배열의 프로토타입은 Array.prototype이란 고유의 객체가 설정됩니다. Array.prototype에는 배열 내장 메서드들이 모두 정의되어있습니다. 또한 Array.prototype 내장 프로토타입 또한 자신의 프로토타입을 갖는데 바로 최상위 프로토타입인 Object.prototype입니다.

스코프(scope)

스코프(scope)는 변수나 매개변수에 접근할 수 있는 범위를 결정합니다. 자바스크립트의 스코프는 함수와 블록 단위의 스코프로 나눌 수 있으며, 함수나 블록의 선언 위치에 따라 중첩된 스코프가 정의될 수 있습니다.

함수 스코프와 블록 스코프

함수 스코프는 말 그대로 선언된 함수 단위로 생성되는 스코프이며, 함수 스코프 안에 선언된 변수나 함수들은 모두 함수 스코프에 포함됩니다.

function foo() {
  var a = 1
  function bar(a) {
    console.log(a, b) // 1, 2
  }
  bar(2)
}
foo()

foo() 함수가 선언되면서 함수 스코프를 생성하며, foo() 함수 스코프에는 변수 a와 또 다른 함수 bar()가 포함됩니다. 여기서 var로 선언한 변수는 함수 스코프를 따르기 때문에, 블록을 무시하고 함수안 어디서든 접근이 가능합니다.

funtion foo() {
    if (true) {
        var a = 1;
    }
    console.log(a); // 1
}
foo();

변수 a는 블록 안에 선언하였지만, var 키워드는 함수 스코프를 따르기 때문에 블록을 무시하고 접근이 가능합니다. 하지만 이경우는 직관적이지 못하니 지양하는게 좋습니다.

블록스코프와 let, const

블록 스코프에서는 변수의 유효범위를 블록({}) 단위로 제한하여 사용할 수 있습니다. let과 const 키워드로 선언된 변수는 블록 스코프를 따르며, 함수 스코프의 문제를 해결할 수 있습니다.

function foo() {
  if (true) {
    const a = 1
  }
  console.log(a) // error
}
foo()

const 키워드를 사용해 선언한 변수a는 블록 안에서만 유효하며, 블록을 벗어나서는 접근할 수 없습니다. 이는 훨씬 직관적이고 명료합니다.

렉시컬 스코프

프로그래밍 언어의 스코프는 대부분 동적 스코프와 렉시컬 스코프 두 가지 방식으로 동작합니다. 동적 스코프는 런타임 중 함수의 호출에 의해 결정되고, 렉시컬 스코프는 변수나 함수를 어디에 작성하였는가에 기초하여 결정됩니다. 대부분의 현대 프로그래밍 언어들은 렉시컬 스코프 규칙을 따르고 있으며, 자바스크립트 역시 렉시컬 스코프를 기반으로 동작하는 언어입니다.

종종 자바스크립트가 동적 스코프를 따른다고 오해하는 개발자들이 있습니다. 이는 자바스크립트의 this 바인딩과 스코프를 착각하기 때문인데요. 자바스크립트의 스코프는 코드가 작성된 문맥에 따라 정적으로 결정되는 렉시컬 스코프를 따릅니다. 다만, this 바인딩만 함수를 호출하는 방법에 따라 동적으로 달라질 뿐입니다.


function foo() {
  var a = 1
  function bar(b) {
    console.log(a, b) // 1, 2
  }
  bar(2)
}
foo()

1단계 console.log() 메서드에서 참조된 변수 a를 찾기 위해 bar() 함수의 스코프부터 검색을 시작합니다.

2단계 bar() 함수의 스코프에는 변수 a를 찾을 수 없으므로 가장 가까운 상위 스코프 foo() 함수 스코프로 올라가 검색합니다.

3단계 foo() 함수 스코프에서 변수 a를 찾아 사용하며, 검색은 여기서 중단됩니다.

여기서 중요한 점은 안쪽부터 상위로 올라가며 검색하기 때문에 상위 스코프에서 안쪽 스코프의 변수나 함수에는 접근할 수 없다는 점입니다.

function foo() {
  var a = 1
  function bar(b) {
    console.log(a, b)
  }
  bar(2)
}
console.log(a) // error
foo()

이러한 스코프들의 연결 관계를 스코프 체인이라고 하며, 스코프 체인을 따라 검색하는 과정을 스코프 체이닝이라고 합니다.

함수 스코프와 블록 스코프는 스코프의 단위이며, 렉시컬 스코프는 이 스코프들의 범위를 결정하는 규칙입니다.

호이스팅(Hoisting)

호이스팅은 선언문이 스코프 내의 가장 최상단으로 끌어올려지는 것을 의미합니다.

console.log(a) // undefined
var a = 1

전역 스코프 내의 변수 a를 선언하기 전에 console.log() 메서드에서 참조하였습니다. 선언하기 전에 접근하였으니 “ReferenceError: a is not defined” 에러가 발생할 것 같지만, 예상과 달리 정상적으로 실핼되어 undefined가 출력됩니다. 이유는 호이스팅 때문입니다. 선언문 var a; 가 전역 스코프의 최상단으로 끌어올려지기 떄문에 선언되기 이전에 참조할 수 있는것입니다. 호이스팅을 변수 생성 과정과 함께 살펴보겠습니다. 자바스크립트의 변수는 세 가지 단계로 나누어 생성됩니다.

  • 선언 : 스코프에 변수를 선언합니다.
  • 초기화 : 변수의 값을 undefined로 초기화하며, 실제로 변수에 접근 가능한 단계입니다.
  • 할당 : 할당문을 만나면 변수에 실제 값을 할당합니다.

var 키워드로 선언한 변수는 선언과 초기화 단게를 한 번에 실행합니다. 그리고 이 두 단계는 스코프의 최상단으로 끌어올려져 실행됩니다. 따라서 선언하기 전에 변수에 접근하여도 이미 초기화가 되어 접근이 가능한 것입니다. 이러한 동작을 호이스팅이라고 합니다.

스코프별로 동작하는 호이스팅

호이스팅은 스코프별로 동작합니다. 전역 스코프가 아닌 함수 내에 선언된 변수는 함수 스코프 안에서 호이스팅이 발생합니다.

function foo() {
  console.log(a)
  var a = 1
}

위 코드는 아래 코드처럼 처리됩니다.

function foo() {
  var a
  console.log(a)
  a = 1
}

let과 const

let과 const 키워드로 선언한 변수는 var 키워드와 다르게 선언과 초기화 단계가 분리되어 실행됩니다. 선언 단계는 스코프의 최상단으로 끌어올려져 실행되지만, 초기화 단계는 선언문을 만나면 실행됩니다. 선언 단계가 실행되는 스코프의 최상단부터 초기화 단계를 실행하는 선언문이 나오기 전까지는 변수에 접근할 수 없습니다. 그리고 이 구간을 Temporal Dead Zone(TDZ)이라고 부릅니다.

console.log(a) // error
let a = 1

let 키워드로 선언된 변수 a를 초기화 단계를 실행하기 전인 TDZ 구간에서 참조하였기 때문에 Error가 발생합니다.

let과 const 키워드로 선언한 변수의 초기화는 선언문을 만나는 시점에 실행되지만, 선언 단계는 스코프의 최상단으로 끌어올려져 실행됩니다.

let a = ;1
function foo() {
    console.log(a); // error
    let a = 1;
}

foo() 함수의 지역 변수 a의 선언 단계는 foo() 함수 스코프의 최상단에서 실행됩니다.

foo() 함수 내에서 전역 변수 a가 아닌 함수 내의 지역 변수 a를 먼저 참조하는 이유는 스코프 체인에 따라 가장 안쪽의 스코프부터 검색하기 때문입니다.

호이스팅은 결국 선언문이 스코프 내의 가장 최상단으로 끌어올려지는 것을 의미하는데, 필자는 const, let도 결국 호이스팅이 발생하는것이 아닌가? 라고 생각한다. 결국 var하고 차이점은 선언과 동시에 초기화를 해주는가 아닌가에 대한 문제인데, 책을 읽어보니 의견차이가 생길만 한 것 같다. 결국 ECMAScript 명세에도 명확한 내용이 없기때문에 확실한 답은 정해지지 않은것 같다. 결론은 var, const, let 모두 선언단계에 스코프 내의 최상단으로 끌어올려져 실행된다는 것이다.

함수 선언문의 호이스팅

함수 선언문 역시 선언문이기 때문에 호이스팅이 발생합니다. 함수 선언문의 호이스팅은 함수 선언, 초기화, 할당 세 가지 단계가 모두 동시에 스코프 최상단에서 실행됩니다. 때문에 어느 위치에서든 함수를 호출할 수 있습니다.

console.log(a())

function a() {
  console.log(1)
}

클로저(closure)

클로저는 함수의 렉시컬 스코프를 기억하여 함수가 렉시컬 스코프를 벗어난 외부 스코프에서 실행될 때에도 자신의 렉시컬 스코프에 접근할 수 있게 해주는 것입니다.

function foo() {
  var a = 1
  function bar() {
    console.log(a) // 1
  }
  bar()
}
foo()

bar() 함수는 상위 스코프인 foo() 함수 내에서 실행되며, 렉시컬 스코프 체인을 통해 foo() 함수의 스코프를 기억하고 있습니다. 그렇지만 bar() 함수를 일반적으로 클로저라고 부르지는 않습니다. 일반적으로 bar()와 같은 내부 함수가 자신을 감싸고 있는 외부 함수 (foo())를 벗어나 완전히 독립적인 스코프에서 실행되었을 경우 클로저라고 부릅니다.

function foo() {
  var a = 1
  function bar() {
    console.log(a) // 1
  }
  return bar
}
const baz = foo()
baz() // 1
  • 1단계 : bar() 함수는 렉시컬 스코프 체인을 통해 foo() 함수의 스코프를 기억합니다.
  • 2단계 : bar() 함수를 전역 변수 baz에 할당하였습니다.
  • 3단계 : 전역 변수 baz를 사용하여 bar() 함수를 호출하였습니다.
  • 4단계 : bar() 함수는 자신의 스코프에서 변수 a를 찾습니다.
  • 5단계 : 자신의 스코프에서는 찾을 수 없기 때문에 스코프 체인을 통해 foo() 함수의 스코프에서 찾습니다.
  • 6단계 : foo() 함수의 스코프에서 변수 a를 찾아 1를 출력합니다.

bar() 함수는 자신이 생성된 렉시컬 스코프에서 벗어나 baz라는 전역 변수로 호출되었습니다. 여기서 중요한 점은 bar() 함수가 자신을 감싸고 있는 foo()함수를 벗어나 bar() 함수의 스코프와 상관없는 전역 스코프에서 실행된다는 점입니다. 그리고 bar() 함수를 실행하였을 때 자신의 렉시컬 스코프 체인을 통해 foo() 함수의 스코프에서 변수 a를 찾습니다. 이것이 바로 클로저입니다. 또한 전역 스코프가 아닌 어느 곳에서 호출되어도 bar() 함수는 기억한 렉시컬 스코프 체인을 통해 변수 a를 찾을 수 있습니다. 즉 클로저를 사용하면 외부에서도 얼마든지 원래 렉시컬 스코프에 접근할 수 있습니다.

function foo() {
  var a = 1
  function bar() {
    console.log(a) // 1
  }
  return bar
}

function baz() {
  const fn = foo()
  fn() // 1
}
baz()

전역 스코프가 아닌 baz() 함수의 내부에서 bar() 함수를 호출하여도 클로저에 의해 bar() 함수의 렉시컬 스코프에 접근할 수 있습니다.